"""
字段提取服务

实现字段提取的主流程：
1. 文件解析（MinerU）
2. 规则匹配
3. LLM字段提取
4. 字段溯源
5. PDF高亮生成
"""

import asyncio
import json
import time
from pathlib import Path
from typing import List, Dict, Any, Optional, Tuple, Union
from concurrent.futures import ThreadPoolExecutor
from loguru import logger

from config.llm_config import LLMConfig
from config.tika_config import TikaConfig
from config.paddle_config import get_paddle_config
from src.services.llm.client import LLMClient, ExtractionAgent, TracingAgent
from src.services.llm.text_matcher import TextMatcher
from src.services.mineru.client import MinerUClient
from src.services.mineru.parser import MinerUParser
from src.services.paddle.client import PaddleClient, PaddleError
from src.services.tika.client import TikaClient
from src.services.rule_config_service import RuleConfigService
from src.services.task_store import get_task_store
from src.schemas.task import TaskType, TaskStatus
from src.utils.extraction_type_mapper import map_extraction_type_to_business_type
from src.schemas.extraction import (
    ExtractionRequest,
    ExtractionData,
    FieldExtractionResult,
    ExtractedFieldValue,
    SourceInfo,
    ExtractionStatus,
    StreamEvent,
    TaskStartEvent,
    FieldProgressEvent,
    FieldCompleteEvent,
    TaskCompleteEvent,
    ErrorEvent
)
from src.schemas.rule_config import ExtractionRule, FieldType
from src.schemas.file import FileType, FILE_TYPE_LABELS
from src.utils.pdf_highlighter import PDFHighlighter
from src.utils.paddle_pdf_highlighter import PaddlePDFHighlighter
from src.utils.file_handler import FileHandler
from src.utils.converters.image_converter import ImageToPDFConverter
from src.utils.converters.word_converter import WordToPDFConverter
from src.utils.file_hash import FileHasher
from src.utils.rag_handler import RagHandler


class ExtractionService:
    """字段提取服务"""
    
    def __init__(self):
        """初始化提取服务"""
        self.rule_service = RuleConfigService()
        
        # Paddle 客户端（优先使用）
        self.paddle_client = PaddleClient()
        self.paddle_config = get_paddle_config()
        
        # MinerU 客户端（降级备选）
        self.mineru_client = MinerUClient()
        self.mineru_parser = MinerUParser()
        
        self.text_matcher = TextMatcher(fuzzy_threshold=0.95)
        self.file_handler = FileHandler()
        self.task_store = get_task_store()
        
        # Tika 客户端
        tika_config = TikaConfig()
        self.tika_client = TikaClient(tika_server_url=tika_config.tika_server_url)
        
        # 图片转换器
        self.image_converter = ImageToPDFConverter()
        
        # LLM配置
        self.llm_config = LLMConfig()
        self.concurrency_config = self.llm_config.get_concurrency_config()
        
        # RAG处理器
        self.rag_handler = RagHandler()
    
    async def extract_fields(
        self,
        request: ExtractionRequest,
        enable_cache: bool = True,
        parsed_files_cache: Optional[Dict[str, Any]] = None
    ) -> ExtractionData:
        """
        执行字段提取
        
        主流程：
        0. 计算附件哈希，尝试从缓存中获取结果（如果enable_cache=True）
        1. 创建任务（如果提供了task_id或request_id）
        2. 加载规则配置
        3. 解析所有文件（MinerU），可复用预解析的文件缓存
        4. 并发提取字段
        5. 并发溯源字段
        6. 生成PDF高亮
        7. 更新任务状态
        
        Args:
            request: 提取请求
            enable_cache: 是否启用缓存查询（默认True）
            parsed_files_cache: 预解析的文件缓存（key为file_url，value为parse_result），
                用于避免重复解析文件，通常来自审核流程的文件分类阶段
            
        Returns:
            ExtractionData: 提取响应数据
        """
        start_time = time.time()
        
        # 生成任务ID
        task_id = request.task_id or request.request_id
        task_created = False
        
        # 计算附件哈希（用于缓存查询）
        files_hash = FileHasher.compute_files_hash(
            files=request.files,
            extraction_type=request.extraction_type,
            rule_config_id=request.rule_config_id
        )
        
        try:
            # 0. 尝试从缓存中获取结果
            if enable_cache and files_hash:
                cached_task = await self.task_store.find_task_by_files_hash(
                    files_hash=files_hash,
                    task_type=TaskType.EXTRACTION,
                    only_completed=True
                )
                
                if cached_task and cached_task.result:
                    logger.info(
                        f"找到缓存结果，直接返回历史任务 {cached_task.task_id} 的结果 "
                        f"(缓存命中，节省处理时间)"
                    )
                    
                    # 复用历史结果，但更新request_id和task_id
                    cached_result = cached_task.result.copy() if isinstance(cached_task.result, dict) else cached_task.result
                    if isinstance(cached_result, dict):
                        # 更新请求ID和任务ID
                        if request.request_id:
                            cached_result["request_id"] = request.request_id
                        if task_id:
                            cached_result["task_id"] = task_id
                        cached_result["from_cache"] = True
                        cached_result["cached_task_id"] = cached_task.task_id
                    
                    # 将缓存结果转换为ExtractionData
                    return ExtractionData(**cached_result) if isinstance(cached_result, dict) else cached_result
            
            # 创建任务（如果有task_id或request_id）
            if task_id:
                await self.task_store.create_task(
                    task_id=task_id,
                    task_type=TaskType.EXTRACTION,
                    context={
                        "extraction_type": request.extraction_type,
                        "rule_config_id": request.rule_config_id
                    },
                    files_hash=files_hash  # 保存附件哈希
                )
                task_created = True
                await self.task_store.update_task(task_id, status=TaskStatus.PROCESSING, progress=0.0)
            # 1. 加载规则配置
            if request.rule_config_id:
                logger.info(f"加载规则配置: {request.rule_config_id}")
                config = self.rule_service.get_config(request.rule_config_id)
                if not config:
                    raise ValueError(f"规则配置不存在: {request.rule_config_id}")
            else:
                # 根据extraction_type查找默认规则配置
                logger.info(f"未提供rule_config_id，尝试根据extraction_type查找默认规则: {request.extraction_type}")
                business_type = map_extraction_type_to_business_type(request.extraction_type)
                
                if business_type is None:
                    raise ValueError(
                        f"无法识别的extraction_type: {request.extraction_type}。"
                        f"支持的类型: 有纸大宗实物, 无纸大宗实物, 有纸小宗实物, 无纸小宗实物"
                    )
                
                config = self.rule_service.get_default_config_by_business_type(business_type)
                
                if config is None:
                    raise ValueError(
                        f"未找到业务类型 '{business_type.value}' 的启用规则配置。"
                        f"请先在规则配置管理中创建并启用对应的规则配置，或者显式提供rule_config_id参数。"
                    )
                
                logger.info(f"使用默认规则配置: {config.id} ({config.name})")
            
            # 更新任务上下文，保存总字段数（用于任务查询时显示进度）
            if task_created and task_id:
                total_fields = len(config.extraction_rules)
                await self.task_store.update_task(
                    task_id,
                    context={
                        "extraction_type": request.extraction_type,
                        "rule_config_id": config.id,
                        "total_fields": total_fields
                    }
                )
            
            # 2. 解析所有文件（复用预解析的文件缓存）
            if parsed_files_cache:
                logger.info(f"开始解析 {len(request.files)} 个文件（复用 {len(parsed_files_cache)} 个预解析文件）")
            else:
                logger.info(f"开始解析 {len(request.files)} 个文件")
            parsed_files = await self._parse_files(request.files, parsed_files_cache=parsed_files_cache)
            parsed_files_snapshot = self.build_parsed_files_snapshot(parsed_files)
            
            # 3. 提取字段
            logger.info(f"开始提取 {len(config.extraction_rules)} 个字段")
            field_results = await self._extract_all_fields(
                extraction_rules=config.extraction_rules,
                parsed_files=parsed_files,
                request=request,
                task_id=task_id,
                task_created=task_created
            )
            
            # 4. 统计结果
            # 成功：至少有一个来源提取成功（values 列表非空）
            success_count = sum(1 for r in field_results if len(r.values) > 0)
            failed_count = len(field_results) - success_count
            
            # 5. 确定整体状态
            if success_count == len(field_results):
                overall_status = ExtractionStatus.SUCCESS
            elif success_count == 0:
                overall_status = ExtractionStatus.FAILED
            else:
                overall_status = ExtractionStatus.PARTIAL
            
            processing_time = int((time.time() - start_time) * 1000)
            
            response = ExtractionData(
                request_id=request.request_id or task_id or f"req_{int(time.time() * 1000)}",
                task_id=task_id if task_created else None,
                status=overall_status,
                extraction_type=request.extraction_type,
                rule_config_id=config.id,  # 使用实际使用的配置ID
                results=field_results,
                total_fields=len(field_results),
                success_count=success_count,
                failed_count=failed_count,
                processing_time_ms=processing_time,
                parsed_files=parsed_files_snapshot
            )
            
            # 更新任务状态为完成
            if task_created and task_id:
                await self.task_store.update_task(
                    task_id,
                    status=TaskStatus.COMPLETED,
                    progress=1.0,
                    result=response.model_dump()
                )
            
            return response
            
        except Exception as e:
            logger.error(f"字段提取失败: {e}", exc_info=True)
            
            # 更新任务状态为失败
            if task_created and task_id:
                await self.task_store.update_task(
                    task_id,
                    status=TaskStatus.FAILED,
                    error_message=str(e)
                )
            
            raise
    
    async def _parse_files(
        self,
        files: List[Any],
        parsed_files_cache: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Dict[str, Any]]:
        """
        解析所有文件并按 file_type 分组（完全并行版本）
        
        处理逻辑：
        1. 所有文件（不同 file_type）一次性并行解析
        2. 对于已在缓存中的文件，直接使用缓存结果，避免重复解析
        3. 解析完成后按 file_type 分组结果
        4. 对每个 file_type 组执行合并操作
        
        合并策略：
           - content_list_json: 合并所有文件的内容列表
           - middle_json: 合并所有文件的页面数据（调整页码）
           - file_names: 记录所有文件名
        
        Args:
            files: 文件列表，每个文件包含 file_type, file_name, file_url
            parsed_files_cache: 预解析的文件缓存（key为file_url，value为parse_result），
                用于避免重复解析
            
        Returns:
            Dict: file_type -> 解析结果的映射
                {
                    "info_disclosure_application": {
                        "file_type": "info_disclosure_application",
                        "file_names": ["申请书1.pdf", "申请书2.pdf"],
                        "file_paths": ["url1", "url2"],
                        "parse_results": [ParseResult1, ParseResult2],
                        "content_list_json": "合并后的内容列表",
                        "middle_json": "合并后的middle数据"
                    }
                }
        """
        from collections import defaultdict
        import asyncio
        
        if not files:
            logger.warning("没有文件需要解析")
            return {}
        
        # 统计缓存命中
        cache_hits = 0
        cache_misses = 0
        
        logger.info(f"开始并行解析所有文件，总共 {len(files)} 个文件")
        
        # 创建并行解析任务的包装函数
        async def parse_with_metadata(file_input):
            """包装解析函数，返回文件信息和解析结果"""
            nonlocal cache_hits, cache_misses
            
            file_type = file_input.file_type.value
            file_url = file_input.file_url
            
            try:
                # 先检查缓存
                if parsed_files_cache and file_url in parsed_files_cache:
                    cache_hits += 1
                    cached_result = parsed_files_cache[file_url]
                    logger.info(f"文件 {file_input.file_name} 命中缓存，跳过解析")
                    return {
                        "success": True,
                        "file_type": file_type,
                        "file_name": file_input.file_name,
                        "file_path": file_url,
                        "parse_result": cached_result,
                        "from_cache": True
                    }
                
                # 缓存未命中，执行解析
                cache_misses += 1
                logger.info(f"开始解析文件: {file_input.file_name} (类型: {file_type})")
                
                # 根据文件类型选择解析方式
                parse_result = await self._parse_single_file(file_input)
                
                if parse_result:
                    logger.info(f"文件解析成功: {file_input.file_name}")
                    return {
                        "success": True,
                        "file_type": file_type,
                        "file_name": file_input.file_name,
                        "file_path": file_url,
                        "parse_result": parse_result,
                        "from_cache": False
                    }
                else:
                    logger.warning(f"文件解析返回空结果: {file_input.file_name}")
                    return {
                        "success": False,
                        "file_type": file_type,
                        "file_name": file_input.file_name
                    }
                    
            except Exception as e:
                logger.error(f"文件解析失败 {file_input.file_name}: {e}", exc_info=True)
                return {
                    "success": False,
                    "file_type": file_type,
                    "file_name": file_input.file_name,
                    "error": str(e)
                }
        
        # 一次性并行解析所有文件（不管什么 file_type）
        tasks = [parse_with_metadata(file_input) for file_input in files]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        # 输出缓存统计
        if parsed_files_cache:
            logger.info(f"文件解析完成，缓存命中: {cache_hits}/{len(files)}，跳过解析节省时间")
        logger.info(f"所有文件解析完成，开始按 file_type 分组结果")
        
        # 按 file_type 分组结果
        results_by_type = defaultdict(list)
        for result in results:
            # 处理可能的异常
            if isinstance(result, Exception):
                logger.error(f"文件解析任务异常: {result}", exc_info=result)
                continue
            
            # 收集成功的结果
            if isinstance(result, dict) and result.get("success"):
                file_type = result["file_type"]
                results_by_type[file_type].append(result)
        
        # 对每个 file_type 组执行合并操作
        parsed_files = {}
        for file_type, type_results in results_by_type.items():
            parse_results = []
            file_names = []
            file_paths = []
            
            for result in type_results:
                parse_results.append(result["parse_result"])
                file_names.append(result["file_name"])
                file_paths.append(result["file_path"])
            
            # 合并该类型的所有解析结果
            parsed_files[file_type] = {
                "file_type": file_type,
                "file_names": file_names,
                "file_paths": file_paths,
                "parse_results": parse_results,
                "md_content": self._merge_md_content(parse_results),
                "middle_json": self._merge_middle_json(parse_results)
            }
            
            logger.info(f"文件类型 {file_type} 合并完成，成功处理 {len(parse_results)} 个文件")
        
        # 统计信息
        total_success = sum(len(results) for results in results_by_type.values())
        logger.info(f"文件解析汇总：总计 {len(files)} 个文件，成功 {total_success} 个，失败 {len(files) - total_success} 个")
        
        return parsed_files
    
    def build_parsed_files_snapshot(self, parsed_files: Dict[str, Any]) -> Dict[str, Any]:
        """
        构建解析文件的快照数据，供其他服务复用

        Args:
            parsed_files: 解析后的原始数据

        Returns:
            Dict[str, Any]: {file_type: {"file_names": [...], "file_label": str, "md_content": str}}
        """
        snapshot: Dict[str, Any] = {}

        for file_type, data in parsed_files.items():
            if not isinstance(data, dict):
                continue

            file_names = data.get("file_names", [])
            md_content = data.get("md_content", "")

            file_label = ""
            try:
                file_label = FILE_TYPE_LABELS.get(FileType(file_type), "")
            except ValueError:
                file_label = data.get("file_label", "")

            snapshot[file_type] = {
                "file_names": file_names,
                "file_label": file_label,
                "md_content": md_content
            }

        return snapshot

    async def _parse_single_file(self, file_input: Any) -> Optional[Any]:
        """
        解析单个文件，根据文件类型选择不同的解析方式
        
        处理策略：
        1. Word 文档：使用 Tika 提取文本用于 LLM 提取，同时转换为 PDF 用于溯源
        2. 文本文档（txt/rtf等）：使用 Tika 提取文本，创建简单的解析结果（不支持溯源）
        3. 图片文件：转换为 PDF，然后优先使用 Paddle 解析，失败则降级到 MinerU
        4. PDF 文件：优先使用 Paddle 解析，失败则降级到 MinerU
        
        解析引擎优先级：
        - 优先使用 Paddle（PaddleOCR-VL + 印章识别）
        - Paddle 服务故障时自动降级到 MinerU
        
        Args:
            file_input: 文件输入对象，包含 file_name, file_url
            
        Returns:
            解析结果对象，如果解析失败返回 None
        """
        from pathlib import Path
        from io import BytesIO
        import tempfile
        
        # 获取文件扩展名
        file_extension = Path(file_input.file_name).suffix.lower()
        
        # 1. 检查是否为 Word 格式（双模式处理：Tika 提取 + PDF 转换）
        if FileHandler.is_word_format(file_extension):
            logger.info(f"检测到 Word 格式: {file_extension}，使用 Tika 提取文本 + Gotenberg 转换用于溯源")
            
            try:
                # 下载文件内容
                file_content, _ = await FileHandler.process_file_input(
                    file_url=file_input.file_url,
                    file_name=file_input.file_name
                )
                
                # 使用 Tika 提取文本（用于 LLM 提取）
                tika_result = await self.tika_client.extract_text(
                    file_content=file_content,
                    file_name=file_input.file_name
                )
                
                # 使用 Gotenberg 转换为 PDF（用于溯源）
                try:
                    pdf_content = await WordToPDFConverter.convert_to_pdf_async(
                        word_content=file_content,
                        file_name=file_input.file_name
                    )
                    
                    # 保存为临时文件，供 MinerU 解析获取坐标信息
                    with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file:
                        tmp_file.write(pdf_content)
                        tmp_pdf_path = tmp_file.name
                    
                    try:
                        # 使用 MinerU 解析 PDF（获取坐标信息）
                        pdf_parse_result = await self.mineru_client.parse_file(
                            file_path=tmp_pdf_path
                        )
                        
                        # 创建混合解析结果：
                        # - md_content 来自 Tika（用于提取）
                        # - middle_json 来自 MinerU（用于溯源）
                        parse_result = self._create_word_parse_result(
                            text_content=tika_result["text"],
                            pdf_parse_result=pdf_parse_result,
                            file_name=file_input.file_name,
                            pdf_content=pdf_content
                        )
                        
                        return parse_result
                        
                    finally:
                        # 清理临时 PDF 文件
                        try:
                            Path(tmp_pdf_path).unlink()
                        except:
                            pass
                            
                except Exception as e:
                    logger.warning(f"Word 转 PDF 失败: {e}，将使用纯文本模式（不支持溯源）")
                    # 如果转换失败，降级为纯文本模式
                    parse_result = self._create_text_parse_result(
                        text_content=tika_result["text"],
                        file_name=file_input.file_name
                    )
                    return parse_result
                
            except Exception as e:
                logger.error(f"Word 文档处理失败: {e}")
                raise
        
        # 2. 检查是否为文本文档格式（使用 Tika，不支持溯源）
        elif FileHandler.is_text_document_format(file_extension):
            logger.info(f"检测到文本文档格式: {file_extension}，使用 Tika 提取文本（不支持溯源）")
            
            try:
                # 下载文件内容
                file_content, _ = await FileHandler.process_file_input(
                    file_url=file_input.file_url,
                    file_name=file_input.file_name
                )
                
                # 使用 Tika 提取文本
                tika_result = await self.tika_client.extract_text(
                    file_content=file_content,
                    file_name=file_input.file_name
                )
                
                # 创建简单的解析结果（模拟 MinerU 的结构）
                parse_result = self._create_text_parse_result(
                    text_content=tika_result["text"],
                    file_name=file_input.file_name
                )
                
                return parse_result
                
            except Exception as e:
                logger.error(f"Tika 提取文本失败: {e}")
                raise
        
        # 3. 检查是否为图片格式（需要转换为 PDF）
        elif FileHandler.is_image_format(file_extension):
            logger.info(f"检测到图片格式: {file_extension}，转换为 PDF 后解析")
            
            try:
                # 下载文件内容
                file_content, _ = await FileHandler.process_file_input(
                    file_url=file_input.file_url,
                    file_name=file_input.file_name
                )
                
                # 转换为 PDF
                pdf_content = ImageToPDFConverter.convert_single_image(
                    image_content=file_content,
                    file_name=file_input.file_name
                )
                
                # 保存为临时文件，供 MinerU 解析
                with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file:
                    tmp_file.write(pdf_content)
                    tmp_path = tmp_file.name
                
                try:
                    # 优先使用 Paddle 解析 PDF
                    try:
                        logger.info(f"优先使用 Paddle 解析图片转换的 PDF: {file_input.file_name}")
                        parse_result = await self.paddle_client.parse_file(
                            file_path=tmp_path
                        )
                        
                        if parse_result and parse_result.success:
                            logger.info(f"Paddle 解析成功: {file_input.file_name}")
                            return parse_result
                        else:
                            raise PaddleError("Paddle 解析返回失败")
                            
                    except (PaddleError, Exception) as e:
                        logger.warning(f"Paddle 解析失败: {e}，降级到 MinerU")
                        
                        if self.paddle_config['error']['fallback_to_mineru']:
                            parse_result = await self.mineru_client.parse_file(
                                file_path=tmp_path
                            )
                            logger.info(f"MinerU 解析成功（降级）: {file_input.file_name}")
                            return parse_result
                        else:
                            raise
                            
                finally:
                    # 清理临时文件
                    try:
                        Path(tmp_path).unlink()
                    except:
                        pass
                        
            except Exception as e:
                logger.error(f"图片转 PDF 失败: {e}")
                raise
        
        # 4. PDF 或其他支持的格式，优先使用 Paddle 解析，失败则降级到 MinerU
        else:
            logger.info(f"开始解析文件: {file_extension}")
            
            # 优先使用 Paddle 解析
            try:
                logger.info(f"优先使用 Paddle 解析文件: {file_input.file_name}")
                parse_result = await self.paddle_client.parse_file(
                    file_path=file_input.file_url
                )
                
                if parse_result and parse_result.success:
                    logger.info(f"Paddle 解析成功: {file_input.file_name}")
                    return parse_result
                else:
                    error_msg = parse_result.error_message if parse_result else "未知错误"
                    logger.warning(f"Paddle 解析返回失败: {error_msg}，降级到 MinerU")
                    raise PaddleError(error_msg)
                    
            except PaddleError as e:
                logger.warning(f"Paddle 服务故障: {e}，降级到 MinerU")
                
                # 检查是否启用降级
                if self.paddle_config['error']['fallback_to_mineru']:
                    logger.info(f"使用 MinerU 解析文件: {file_input.file_name}")
                    try:
                        parse_result = await self.mineru_client.parse_file(
                            file_path=file_input.file_url
                        )
                        logger.info(f"MinerU 解析成功（降级）: {file_input.file_name}")
                        return parse_result
                    except Exception as mineru_error:
                        logger.error(f"MinerU 解析也失败: {mineru_error}")
                        raise
                else:
                    logger.error(f"Paddle 解析失败且未启用降级策略")
                    raise
            
            except Exception as e:
                logger.error(f"文件解析异常: {e}，尝试降级到 MinerU")
                
                # 尝试降级到 MinerU
                if self.paddle_config['error']['fallback_to_mineru']:
                    try:
                        parse_result = await self.mineru_client.parse_file(
                            file_path=file_input.file_url
                        )
                        logger.info(f"MinerU 解析成功（降级）: {file_input.file_name}")
                        return parse_result
                    except Exception as mineru_error:
                        logger.error(f"MinerU 解析也失败: {mineru_error}")
                        raise
                else:
                    raise
    
    def _create_word_parse_result(
        self,
        text_content: str,
        pdf_parse_result: Any,
        file_name: str,
        pdf_content: bytes
    ) -> Any:
        """
        为 Word 文档创建混合解析结果对象
        
        结合 Tika 提取的文本（用于 LLM 提取）和 MinerU 解析的 PDF（用于溯源）
        
        Args:
            text_content: Tika 提取的文本内容
            pdf_parse_result: MinerU 解析 PDF 的结果
            file_name: 文件名
            pdf_content: 转换后的 PDF 内容
            
        Returns:
            解析结果对象
        """
        from types import SimpleNamespace
        
        # 创建混合解析结果：
        # - md_content 使用 Tika 提取的文本（更准确，用于 LLM 提取）
        # - pages/layout_blocks 使用 MinerU 解析的结果（包含坐标，用于溯源）
        parse_result = SimpleNamespace(
            pages=pdf_parse_result.pages,
            file_name=file_name,
            total_pages=len(pdf_parse_result.pages),
            # 标记为 Word 模式（支持溯源）
            is_text_only=False,
            is_word_document=True,
            raw_text=text_content,
            # 设置 md_content 为 Tika 提取的文本
            md_content=text_content,
            # 保存 PDF 内容用于后续生成高亮
            pdf_content=pdf_content
        )
        
        return parse_result
    
    def _create_text_parse_result(self, text_content: str, file_name: str) -> Any:
        """
        为纯文本创建解析结果对象（模拟 MinerU 的结构）
        
        Args:
            text_content: 提取的文本内容
            file_name: 文件名
            
        Returns:
            解析结果对象
        """
        from types import SimpleNamespace
        
        # 创建模拟的 content 对象
        mock_content = SimpleNamespace(
            type="text",
            text=text_content
        )
        
        # 创建模拟的 layout_block 对象（用于 middle_json）
        mock_layout_block = SimpleNamespace(
            bbox=SimpleNamespace(x0=0, y0=0, x1=100, y1=100),
            category_type="text",
            lines=[
                SimpleNamespace(
                    spans=[
                        SimpleNamespace(text=text_content)
                    ]
                )
            ]
        )
        
        # 创建模拟的 page 对象（兼容 MinerU 的结构）
        mock_page = SimpleNamespace(
            page_num=0,  # 注意：这里是 page_num，不是 page_idx
            text=text_content,
            width=0,
            height=0,
            contents=[mock_content],  # 添加 contents 属性
            layout_blocks=[mock_layout_block]  # 添加 layout_blocks 属性
        )
        
        # 创建一个简单的命名空间对象来模拟 MinerU 的 ParseResult
        parse_result = SimpleNamespace(
            pages=[mock_page],
            file_name=file_name,
            total_pages=1,
            # 标记为文本提取模式（不支持溯源）
            is_text_only=True,
            raw_text=text_content,
            # 设置 md_content 以便后续合并使用
            md_content=text_content
        )
        
        return parse_result
    
    def _merge_md_content(self, parse_results: List[Any]) -> str:
        """
        合并多个文件的 Markdown 内容
        
        Args:
            parse_results: ParseResult 对象列表
            
        Returns:
            str: 合并后的 Markdown 内容
        """
        try:
            md_contents = []
            
            for i, parse_result in enumerate(parse_results):
                # 提取 md_content
                md = getattr(parse_result, 'md_content', None)
                
                if md:
                    # 如果有多个文件，添加分隔符标识不同文件
                    if len(parse_results) > 1:
                        file_name = getattr(parse_result, 'file_name', f'文件{i+1}')
                        md_contents.append(f"# === {file_name} ===\n\n{md}")
                    else:
                        md_contents.append(md)
                else:
                    logger.warning(f"解析结果 {i} 没有 md_content，尝试从其他属性提取文本")
                    # 尝试多种方式获取文本内容
                    text = ""
                    
                    # 1. 尝试调用 get_all_text 方法
                    if hasattr(parse_result, 'get_all_text'):
                        text = parse_result.get_all_text()
                    # 2. 尝试从 raw_text 属性获取
                    elif hasattr(parse_result, 'raw_text'):
                        text = getattr(parse_result, 'raw_text', "")
                    # 3. 尝试从 pages 中提取
                    elif hasattr(parse_result, 'pages'):
                        page_texts = []
                        for page in parse_result.pages:
                            if hasattr(page, 'text'):
                                page_texts.append(page.text)
                        text = "\n".join(page_texts)
                    
                    if text:
                        md_contents.append(text)
                    else:
                        logger.warning(f"解析结果 {i} 无法提取任何文本内容")
            
            merged_md = "\n\n---\n\n".join(md_contents)
            logger.info(f"合并 md_content：共 {len(merged_md)} 字符，来自 {len(parse_results)} 个文件")
            return merged_md
            
        except Exception as e:
            logger.error(f"合并 md_content 失败: {e}", exc_info=True)
            return ""
    
    def _merge_content_list_json(self, parse_results: List[Any]) -> str:
        """
        合并多个文件的 content_list 内容
        
        Args:
            parse_results: ParseResult 对象列表
            
        Returns:
            str: 合并后的 content_list JSON 字符串
        """
        try:
            all_content = []
            page_offset = 0
            
            for parse_result in parse_results:
                # 提取单个文件的 content_list
                content_list = self._extract_content_list_from_single(parse_result, page_offset)
                all_content.extend(content_list)
                
                # 更新页码偏移量
                page_offset += len(parse_result.pages)
            
            logger.info(f"合并 content_list：共 {len(all_content)} 项内容，来自 {len(parse_results)} 个文件")
            return json.dumps(all_content, ensure_ascii=False, indent=2)
            
        except Exception as e:
            logger.error(f"合并 content_list 失败: {e}", exc_info=True)
            return "[]"
    
    def _merge_middle_json(self, parse_results: List[Any]) -> str:
        """
        合并多个文件的 middle.json 内容
        
        Args:
            parse_results: ParseResult 对象列表
            
        Returns:
            str: 合并后的 middle.json JSON 字符串
        """
        try:
            all_pages = []
            page_offset = 0
            
            for parse_result in parse_results:
                # 提取单个文件的 middle 数据
                pages = self._extract_middle_from_single(parse_result, page_offset)
                all_pages.extend(pages)
                
                # 更新页码偏移量
                page_offset += len(parse_result.pages)
            
            logger.info(f"合并 middle_json：共 {len(all_pages)} 页，来自 {len(parse_results)} 个文件")
            return json.dumps(all_pages, ensure_ascii=False, indent=2)
            
        except Exception as e:
            logger.error(f"合并 middle_json 失败: {e}", exc_info=True)
            return "[]"
    
    def _extract_content_list_from_single(self, parse_result: Any, page_offset: int = 0) -> List[Dict]:
        """
        从单个解析结果中提取 content_list 内容
        
        Args:
            parse_result: MinerU解析结果或纯文本解析结果
            page_offset: 页码偏移量（用于合并多个文件时调整页码）
            
        Returns:
            List[Dict]: content_list 数组
        """
        try:
            # 检查是否为纯文本模式
            is_text_only = getattr(parse_result, "is_text_only", False)
            
            if is_text_only:
                logger.debug("检测到纯文本模式，使用简化的内容提取")
                # 纯文本模式：直接返回简化的内容列表
                raw_text = getattr(parse_result, "raw_text", "")
                if raw_text:
                    return [{
                        "type": "text",
                        "text": raw_text,
                        "page_idx": page_offset
                    }]
                return []
            
            content_list = []
            
            for page in parse_result.pages:
                # 应用页码偏移量
                page_idx = page.page_num + page_offset
                
                # 遍历页面内容
                for content in page.contents:
                    # 基础字段
                    content_item = {
                        "type": content.type,
                        "page_idx": page_idx
                    }
                    
                    # 根据内容类型添加特定字段
                    if content.type == "text":
                        content_item["text"] = content.text if hasattr(content, 'text') else ""
                        # 如果有 text_level，添加标题级别
                        if hasattr(content, 'text_level'):
                            content_item["text_level"] = content.text_level
                    
                    elif content.type == "table":
                        # 表格内容
                        if hasattr(content, 'table_body'):
                            content_item["table_body"] = content.table_body
                        elif hasattr(content, 'html'):
                            content_item["table_body"] = content.html
                        elif hasattr(content, 'text'):
                            content_item["table_body"] = content.text
                        
                        # 可选字段
                        if hasattr(content, 'img_path'):
                            content_item["img_path"] = content.img_path
                        if hasattr(content, 'table_caption') and content.table_caption:
                            content_item["table_caption"] = content.table_caption
                        else:
                            content_item["table_caption"] = []
                        if hasattr(content, 'table_footnote') and content.table_footnote:
                            content_item["table_footnote"] = content.table_footnote
                        else:
                            content_item["table_footnote"] = []
                    
                    elif content.type in ["image", "figure"]:
                        # 图片内容
                        if hasattr(content, 'img_path'):
                            content_item["img_path"] = content.img_path
                        if hasattr(content, 'image_caption') and content.image_caption:
                            content_item["image_caption"] = content.image_caption
                        else:
                            content_item["image_caption"] = []
                        if hasattr(content, 'image_footnote') and content.image_footnote:
                            content_item["image_footnote"] = content.image_footnote
                        else:
                            content_item["image_footnote"] = []
                    
                    else:
                        # 其他类型（formula, list, code等）
                        if hasattr(content, 'text'):
                            content_item["text"] = content.text
                        elif hasattr(content, 'html'):
                            content_item["text"] = content.html
                    
                    # 只添加有实际内容的项
                    if content_item.get("text") or content_item.get("table_body") or content_item.get("img_path"):
                        content_list.append(content_item)
            
            return content_list
            
        except Exception as e:
            logger.error(f"提取 content_list 失败: {e}", exc_info=True)
            return []
    
    def _extract_middle_from_single(self, parse_result: Any, page_offset: int = 0) -> List[Dict]:
        """
        从单个解析结果中提取 middle.json 内容
        
        Args:
            parse_result: MinerU解析结果或纯文本解析结果
            page_offset: 页码偏移量（用于合并多个文件时调整页码）
            
        Returns:
            List[Dict]: middle 数据数组
        """
        try:
            # 检查是否为纯文本模式
            is_text_only = getattr(parse_result, "is_text_only", False)
            
            if is_text_only:
                logger.debug("检测到纯文本模式，使用简化的 middle 数据")
                # 纯文本模式：返回空的 middle 数据（因为没有位置信息）
                # 或者返回一个简化的结构
                return [{
                    "page_idx": page_offset,
                    "layout_dets": []  # 纯文本没有布局信息
                }]
            
            middle_data = []
            
            for page in parse_result.pages:
                # 应用页码偏移量
                page_data = {
                    "page_idx": page.page_num + page_offset,
                    "layout_dets": []
                }
                
                # 添加布局检测数据（包含完整的 lines 和 spans 结构）
                for block in page.layout_blocks:
                    # 兼容处理：支持 bbox 为对象或列表
                    if hasattr(block.bbox, 'x0'):
                        # MinerU 格式：bbox 是对象
                        bbox = [block.bbox.x0, block.bbox.y0, block.bbox.x1, block.bbox.y1]
                    elif isinstance(block.bbox, (list, tuple)) and len(block.bbox) == 4:
                        # Paddle 格式：bbox 是列表
                        bbox = list(block.bbox)
                    else:
                        logger.warning(f"无法识别的 bbox 格式: {type(block.bbox)}")
                        bbox = [0, 0, 0, 0]
                    
                    # ✅ 构建完整的 lines 和 spans 结构
                    lines_data = []
                    all_texts = []
                    
                    for line in block.lines:
                        # 提取 line 的 bbox
                        line_bbox = [0, 0, 0, 0]
                        if hasattr(line, 'bbox'):
                            if hasattr(line.bbox, 'x0'):
                                line_bbox = [line.bbox.x0, line.bbox.y0, line.bbox.x1, line.bbox.y1]
                            elif isinstance(line.bbox, (list, tuple)) and len(line.bbox) == 4:
                                line_bbox = list(line.bbox)
                        
                        # 提取 spans
                        spans_data = []
                        line_texts = []
                        
                        for span in line.spans:
                            # 提取 span 的 bbox
                            span_bbox = [0, 0, 0, 0]
                            if hasattr(span, 'bbox'):
                                if hasattr(span.bbox, 'x0'):
                                    span_bbox = [span.bbox.x0, span.bbox.y0, span.bbox.x1, span.bbox.y1]
                                elif isinstance(span.bbox, (list, tuple)) and len(span.bbox) == 4:
                                    span_bbox = list(span.bbox)
                            
                            span_text = span.text if hasattr(span, 'text') else ""
                            
                            if span_text:  # 只添加有文本的 span
                                spans_data.append({
                                    "bbox": span_bbox,
                                    "content": span_text,  # ⚠️ 使用 'content' 字段，与溯源提示词保持一致
                                    "type": getattr(span, 'type', 'text')
                                })
                                line_texts.append(span_text)
                        
                        # 只添加有内容的 line
                        if spans_data:
                            lines_data.append({
                                "bbox": line_bbox,
                                "spans": spans_data
                            })
                            all_texts.extend(line_texts)
                    
                    # ✅ 构建完整的 layout_det 结构
                    layout_det = {
                        "bbox": bbox,
                        "text": " ".join(all_texts),  # 保留文本字段用于快速搜索
                        "category_type": block.category_type,
                        "lines": lines_data  # ⚠️ 新增：保留完整的 lines 和 spans 结构
                    }
                    
                    page_data["layout_dets"].append(layout_det)
                
                middle_data.append(page_data)
            
            return middle_data
            
        except Exception as e:
            logger.error(f"提取 middle 失败: {e}")
            return []
    
    async def _extract_all_fields(
        self,
        extraction_rules: List[ExtractionRule],
        parsed_files: Dict[str, Dict[str, Any]],
        request: ExtractionRequest,
        task_id: Optional[str] = None,
        task_created: bool = False
    ) -> List[FieldExtractionResult]:
        """
        提取所有字段
        
        使用并发方式提取多个字段，并实时更新任务进度
        
        Args:
            extraction_rules: 提取规则列表
            parsed_files: 解析后的文件数据
            request: 原始请求
            task_id: 任务ID（可选）
            task_created: 是否已创建任务
            
        Returns:
            List[FieldExtractionResult]: 字段提取结果列表
        """
        # 筛选启用的规则
        enabled_rules = [rule for rule in extraction_rules if rule.enabled]
        total_fields = len(enabled_rules)
        
        if total_fields == 0:
            return []
        
        # 创建提取任务列表
        tasks = []
        
        for rule in enabled_rules:
            task = self._extract_single_field(
                rule=rule,
                parsed_files=parsed_files,
                request=request
            )
            tasks.append(task)
        
        # 并发执行，限制并发数，同时追踪完成进度
        semaphore = asyncio.Semaphore(self.concurrency_config.extraction_workers)
        
        async def bounded_task(idx: int, task):
            """包装任务，返回索引和结果"""
            async with semaphore:
                result = await task
                return idx, result
        
        # 创建带索引的任务
        bounded_tasks = [
            bounded_task(idx, task) 
            for idx, task in enumerate(tasks)
        ]
        
        # 使用 as_completed 追踪任务完成情况
        results = [None] * total_fields  # 预分配结果列表
        completed_count = 0
        
        # 逐个等待任务完成，实时更新进度
        for coro in asyncio.as_completed(bounded_tasks):
            idx, result = await coro
            results[idx] = result
            completed_count += 1
            
            # 更新任务进度
            if task_created and task_id:
                progress = completed_count / total_fields
                await self.task_store.update_task(task_id, progress=progress)
                logger.debug(f"提取进度更新: {completed_count}/{total_fields} ({progress:.2%})")
        
        return results
    
    async def _extract_single_field(
        self,
        rule: ExtractionRule,
        parsed_files: Dict[str, Dict[str, Any]],
        request: ExtractionRequest
    ) -> FieldExtractionResult:
        """
        提取单个字段（核心方法）- 支持多来源提取
        
        完整流程：
        1. 【查找文件】遍历 reference_materials，调用 _find_file_by_material 
           找到对应的已解析文件数据
        
        2. 【LLM 提取】调用 _call_extraction_agent
           - 传入：file_data["content_list_json"]（文档的结构化内容）
           - 作用：LLM 根据 extraction_logic 从文档中识别字段值
           - 返回：提取到的字段值
        
        3. 【溯源匹配】调用 _trace_field
           - 传入：提取的值 + file_data["middle_json"]（带坐标的内容）
           - 作用：找到提取值在文档中的位置（页码、坐标）
           - 方式：先字符串匹配，失败则调用 LLM 溯源
        
        4. 【生成高亮】调用 _generate_highlight
           - 传入：文件路径 + 页码 + 坐标
           - 作用：生成 PDF 预览图，高亮显示提取的内容
        
        5. 【构建结果】返回 FieldExtractionResult
           - 包含：所有来源的提取值、溯源信息、预览图等
        
        Args:
            rule: 提取规则（包含字段名、提取逻辑、参考材料等）
            parsed_files: 解析后的文件数据字典
                格式：{"file_type": {"content_list_json": ..., "middle_json": ...}}
            request: 原始请求
            
        Returns:
            FieldExtractionResult: 字段提取结果（包含所有来源的值）
        """
        logger.info(f"提取字段: {rule.field_name}，从 {len(rule.reference_materials)} 个参考材料中提取")
        
        try:
            # 构建extraction_logic（包含字段类型信息）
            extraction_logic = self._build_extraction_logic(rule)
            
            # 收集所有来源的提取值
            extracted_values = []
            
            # 遍历所有reference_materials进行提取
            for ref_material in rule.reference_materials:
                # 找到对应的解析文件
                file_data = self._find_file_by_material(ref_material, parsed_files)
                if not file_data:
                    logger.warning(f"未找到参考材料对应的文件: {ref_material}")
                    continue
                
                # 提取字段
                md_content = file_data.get("md_content", "")
                middle_json_content = None  # 用于 RAG 后的 middle_json
                
                # === RAG 处理 ===
                if self.rag_handler.should_use_rag(len(md_content)):
                    logger.info(f"内容过长 ({len(md_content)} 字符)，启用 RAG 检索")
                    parse_results = file_data.get("parse_results", [])
                    query = f"{rule.field_name} {rule.extraction_logic}"
                    md_content, middle_json_content = self.rag_handler.get_relevant_content(
                        parse_results, query
                    )
                # ================
                
                logger.info(f"准备调用提取Agent，md_content 长度: {len(md_content)} 字符")
                
                extraction_result = await self._call_extraction_agent(
                    file_content=md_content,
                    field_name=rule.field_name,
                    extraction_logic=extraction_logic,
                    reference_materials=ref_material
                )
                
                if not extraction_result.success or not extraction_result.extracted_value:
                    logger.warning(f"字段 {rule.field_name} 从 {ref_material} 提取失败")
                    # 继续尝试下一个材料
                    continue
                
                # 检查是否为纯文本提取模式（来自 word/txt 等文档）
                is_text_only = self._is_text_only_mode(file_data)
                
                # 溯源（如果不是纯文本模式）
                tracing_result = None
                if not is_text_only:
                    # 如果使用了 RAG，使用过滤后的 middle_json，否则使用 file_data 中的原始值
                    middle_json_len = len(middle_json_content) if middle_json_content else len(file_data.get("middle_json", ""))
                    logger.info(f"准备调用溯源Agent，middle_json 长度: {middle_json_len} 字符")
                    
                    tracing_result = await self._trace_field(
                        extracted_value=extraction_result.extracted_value,
                        field_name=rule.field_name,
                        extraction_logic=extraction_logic,
                        reference_materials=ref_material,
                        file_data=file_data,
                        middle_json_content=middle_json_content
                    )
                else:
                    logger.info(f"字段 {rule.field_name} 来自纯文本文档，跳过溯源")
                
                # 生成PDF高亮（如果有溯源结果）- V2: 支持多 regions
                preview_image = None
                cross_page_info = None  # 跨页信息
                
                if tracing_result and tracing_result.success:
                    # V2: 优先使用 regions（支持多区域/跨页）
                    regions = tracing_result.regions if tracing_result.regions else []
                    
                    # 兼容旧版：如果 regions 为空但有 bbox，构造单个 region
                    if not regions and tracing_result.bbox:
                        from src.services.llm.models import Region
                        regions = [Region(
                            page_idx=tracing_result.page_idx or 0,
                            page_number=tracing_result.page_number or 1,
                            bbox=tracing_result.bbox,
                            text_segment=None
                        )]
                    
                    if regions:
                        # 准备 PDF 输入和解析后端
                        parse_results = file_data.get("parse_results", [])
                        
                        # 使用第一个 region 的页码定位文件
                        first_region = regions[0]
                        file_path, file_index = self._locate_file_by_page(
                            file_data, 
                            first_region.page_idx
                        )
                        
                        if file_path is not None or file_index >= 0:
                            # 检查是否为 Word 文档
                            pdf_input = file_path
                            if file_index >= 0 and file_index < len(parse_results):
                                parse_result = parse_results[file_index]
                                if getattr(parse_result, "is_word_document", False):
                                    pdf_content = getattr(parse_result, "pdf_content", None)
                                    if pdf_content:
                                        logger.info(f"使用 Word 转换后的 PDF 内容生成高亮")
                                        pdf_input = pdf_content
                                    else:
                                        logger.warning(f"Word 文档缺少 pdf_content，无法生成高亮")
                                        pdf_input = None
                            
                            if pdf_input is not None:
                                # 获取解析后端
                                backend = "mineru"
                                if parse_results and file_index >= 0 and file_index < len(parse_results):
                                    parse_result = parse_results[file_index]
                                    backend = getattr(parse_result, "backend", "mineru")
                                
                                # 构建 page_bboxes 列表（用于合并高亮）
                                page_bboxes = []
                                involved_pages = set()
                                
                                for region in regions:
                                    # 计算实际页码（考虑文件偏移）
                                    actual_page_idx = self._calculate_actual_page_idx(
                                        file_data,
                                        file_index,
                                        region.page_idx
                                    )
                                    page_bboxes.append((actual_page_idx, region.bbox))
                                    involved_pages.add(actual_page_idx)
                                
                                # 判断是单页还是跨页
                                if len(involved_pages) > 1:
                                    logger.info(f"检测到跨页高亮，涉及页面: {sorted(involved_pages)}，将合并为一张图片")
                                    cross_page_info = {
                                        "is_cross_page": True,
                                        "pages": sorted(involved_pages),
                                        "main_page": min(involved_pages)
                                    }
                                    
                                    # 生成合并的跨页高亮图
                                    preview_image = await self._generate_merged_highlight(
                                        file_path=pdf_input,
                                        page_bboxes=page_bboxes,
                                        backend=backend
                                    )
                                else:
                                    # 单页但可能有多个框
                                    logger.info(f"单页高亮，共 {len(page_bboxes)} 个区域")
                                    preview_image = await self._generate_merged_highlight(
                                        file_path=pdf_input,
                                        page_bboxes=page_bboxes,
                                        backend=backend
                                    )
                
                # 构建溯源信息列表
                sources = []
                if tracing_result and tracing_result.success:
                    # 使用第一个文件名作为代表（或者所有文件名）
                    source_file_name = ", ".join(file_data["file_names"])
                    
                    # 构建坐标信息
                    coordinates = None
                    if tracing_result.bbox and len(tracing_result.bbox) == 4:
                        x0, y0, x1, y1 = tracing_result.bbox
                        coordinates = {
                            "x": int(x0),
                            "y": int(y0),
                            "width": int(x1 - x0),
                            "height": int(y1 - y0)
                        }
                    
                    sources.append(SourceInfo(
                        source_file_type=file_data["file_type"],
                        source_file_name=source_file_name,
                        source_field=rule.field_name,
                        source_content=tracing_result.matched_text or "",
                        preview_image_url=preview_image or "",
                        page_number=tracing_result.page_number,  # 页面号（从1开始）
                        coordinates=coordinates or {"x": 0, "y": 0, "width": 0, "height": 0},
                        confidence=0.9,  # 固定置信度
                        # 跨页信息（如果存在）
                        cross_page_info=cross_page_info
                    ))
                
                # 添加到提取值列表
                extracted_values.append(ExtractedFieldValue(
                    source_file_type=file_data["file_type"],
                    source_file_name=", ".join(file_data["file_names"]),
                    source_field_name=rule.field_name,
                    extracted_value=extraction_result.extracted_value,
                    extract_status=ExtractionStatus.SUCCESS,
                    confidence=extraction_result.confidence if (hasattr(extraction_result, 'confidence') and extraction_result.confidence is not None) else 0.8,
                    sources=sources
                ))
                
                logger.info(f"成功从 {ref_material} 提取字段 {rule.field_name}")
            
            # 构建提取规则信息（用于前端展示）
            extraction_rule_info = {
                "rule_id": rule.id,
                "field_name": rule.field_name,
                "field_type": rule.field_type.value,
                "extraction_logic": rule.extraction_logic,
                "reference_materials": rule.reference_materials,
                "enabled": rule.enabled
            }
            
            # 判断整体状态
            if len(extracted_values) > 0:
                # 有至少一个来源提取成功
                return FieldExtractionResult(
                    field_id=rule.id,
                    field_name=rule.field_name,
                    field_type=rule.field_type.value,
                    values=extracted_values,
                    error_message=None,
                    extraction_rule=extraction_rule_info
                )
            else:
                # 所有材料都提取失败
                return FieldExtractionResult(
                    field_id=rule.id,
                    field_name=rule.field_name,
                    field_type=rule.field_type.value,
                    values=[],
                    error_message="无法从任何参考材料中提取字段",
                    extraction_rule=extraction_rule_info
                )
            
        except Exception as e:
            logger.error(f"提取字段 {rule.field_name} 异常: {e}", exc_info=True)
            
            # 构建提取规则信息（即使异常也要返回）
            extraction_rule_info = {
                "rule_id": rule.id,
                "field_name": rule.field_name,
                "field_type": rule.field_type.value,
                "extraction_logic": rule.extraction_logic,
                "reference_materials": rule.reference_materials,
                "enabled": rule.enabled
            }
            
            return FieldExtractionResult(
                field_id=rule.id,
                field_name=rule.field_name,
                field_type=rule.field_type.value,
                values=[],
                error_message=str(e),
                extraction_rule=extraction_rule_info
            )
    
    def _build_extraction_logic(self, rule: ExtractionRule) -> str:
        """
        构建提取逻辑
        
        将字段类型、选项等信息融入extraction_logic
        
        Args:
            rule: 提取规则
            
        Returns:
            str: 完整的提取逻辑描述
        """
        logic_parts = [rule.extraction_logic]
        
        # 添加字段类型信息
        if rule.field_type == FieldType.SINGLE_CHOICE and rule.choices:
            logic_parts.append(f"这是一个单选字段，可选项包括：{', '.join(rule.choices)}")
        elif rule.field_type == FieldType.MULTIPLE_CHOICE and rule.choices:
            logic_parts.append(f"这是一个多选字段，可选项包括：{', '.join(rule.choices)}")
        elif rule.field_type == FieldType.TEXT:
            logic_parts.append("这是一个文本字段，请提取完整的文本内容")
        
        return "。".join(logic_parts)
    
    def _find_file_by_material(
        self,
        material: str,
        parsed_files: Dict[str, Dict[str, Any]]
    ) -> Optional[Dict[str, Any]]:
        """
        根据参考材料名称查找对应的已解析文件数据
        
        核心逻辑：
        1. reference_materials 中的值（如"《信息披露申请书》"）是文件类型的中文名称
        2. 通过 LABEL_TO_FILE_TYPE 反向映射找到对应的 file_type 枚举值
        3. 根据 file_type 在 parsed_files 中查找解析结果
        
        示例：
        - material: "《信息披露申请书》"
        - 映射为: "info_disclosure_application"
        - 查找: parsed_files["info_disclosure_application"]
        
        Args:
            material: 参考材料名称（如"《信息披露申请书》"）
            parsed_files: 解析后的文件字典，格式：
                {
                    "info_disclosure_application": {
                        "file_type": "info_disclosure_application",
                        "file_names": ["申请书1.pdf", "申请书2.pdf"],
                        "content_list_json": "合并后的内容",
                        "middle_json": "合并后的middle数据"
                    }
                }
            
        Returns:
            Optional[Dict]: 文件数据字典；未找到返回None
        """
        from src.schemas.file import LABEL_TO_FILE_TYPE
        
        logger.debug(f"查找参考材料: {material}")
        
        # 通过反向映射找到 file_type
        file_type = LABEL_TO_FILE_TYPE.get(material)
        
        if file_type is None:
            logger.warning(f"无法识别的参考材料: {material}")
            return None
        
        # 在 parsed_files 中查找对应的 file_type
        file_type_value = file_type.value
        file_data = parsed_files.get(file_type_value)
        
        if file_data:
            file_names = file_data.get("file_names", [])
            logger.info(f"找到参考材料 {material} → {file_type_value} → {len(file_names)} 个文件")
            return file_data
        else:
            logger.warning(f"未找到参考材料对应的文件: {material} (file_type: {file_type_value})")
            return None
    
    async def _call_extraction_agent(
        self,
        file_content: str,
        field_name: str,
        extraction_logic: str,
        reference_materials: str
    ):
        """
        调用 LLM Agent 从文档内容中提取字段值
        
        这个方法的作用：
        1. 接收从 MinerU 解析出的文档内容（Markdown 格式）
        2. 调用 ExtractionAgent，使用 LLM 根据提取逻辑识别字段值
        3. 返回提取结果
        
        Args:
            file_content: 文档内容的 Markdown 字符串
                包含文档的所有文本、表格、标题等内容
            field_name: 要提取的字段名称
            extraction_logic: 提取逻辑描述（告诉 LLM 如何提取这个字段）
            reference_materials: 参考材料名称
            
        Returns:
            提取结果对象，包含提取到的值和是否成功
        """
        extraction_config = self.llm_config.get_extraction_agent_config()
        
        logger.debug(f"调用 ExtractionAgent 提取字段: {field_name}")
        logger.debug(f"文档内容长度: {len(file_content)} 字符")
        
        async with LLMClient(extraction_config) as client:
            agent = ExtractionAgent(client)
            return await agent.extract_field(
                file_content=file_content,
                field_name=field_name,
                extraction_logic=extraction_logic,
                reference_materials=reference_materials
            )
    
    def _is_text_only_mode(self, file_data: Dict[str, Any]) -> bool:
        """
        检查文件是否为纯文本提取模式（不支持溯源）
        
        Args:
            file_data: 文件数据字典
            
        Returns:
            bool: 是否为纯文本模式
        """
        parse_results = file_data.get("parse_results", [])
        if not parse_results:
            return False
        
        # 检查是否所有解析结果都标记为纯文本模式
        return all(
            getattr(result, "is_text_only", False) 
            for result in parse_results
        )
    
    async def _trace_field(
        self,
        extracted_value: str,
        field_name: str,
        extraction_logic: str,
        reference_materials: str,
        file_data: Dict[str, Any],
        middle_json_content: Optional[str] = None
    ):
        """
        溯源字段
        
        先尝试字符串匹配，失败则调用LLM溯源
        """
        middle_json = middle_json_content if middle_json_content is not None else file_data["middle_json"]
        
        # 1. 尝试文本匹配
        match_result = self.text_matcher.find_in_middle_json(
            middle_json_content=middle_json,
            search_text=extracted_value,
            field_name=field_name
        )
        
        if match_result and match_result.success:
            logger.info(f"字段 {field_name} 文本匹配溯源成功")
            return match_result
        
        # 2. 调用LLM溯源
        logger.info(f"字段 {field_name} 文本匹配失败，调用LLM溯源")
        tracing_config = self.llm_config.get_tracing_agent_config()
        
        async with LLMClient(tracing_config) as client:
            agent = TracingAgent(client)
            return await agent.trace_field(
                file_content=middle_json,
                field_name=field_name,
                extraction_logic=extraction_logic,
                reference_materials=reference_materials,
                extraction_result=extracted_value
            )
    
    async def _generate_highlight(
        self,
        file_path: Union[str, bytes],
        page_idx: int,
        bbox: List[float],
        backend: str = "mineru"  # 新增参数：解析后端
    ) -> Optional[str]:
        """
        生成PDF高亮（支持多backend）
        
        Args:
            file_path: PDF文件路径、URL 或 bytes 内容
            page_idx: 页码索引
            bbox: 边界框坐标
            backend: 解析后端（'paddle' 或 'mineru'），默认 'mineru'
            
        Returns:
            Optional[str]: MinIO URL 或 None（失败时）
        """
        try:
            # 判断输入类型
            pdf_input = file_path
            
            # 如果是字符串且为URL，下载文件内容
            if isinstance(file_path, str) and file_path.startswith(('http://', 'https://')):
                # 从URL下载文件内容
                pdf_content, _ = await FileHandler._download_from_url(file_path)
                pdf_input = pdf_content
            # 如果是 bytes，直接使用
            elif isinstance(file_path, bytes):
                pdf_input = file_path
            # 如果是本地路径字符串，直接使用
            
            # ✅ 根据 backend 选择高亮器
            if backend == "paddle":
                # 使用 Paddle 专用高亮器（144 DPI，像素坐标）
                logger.info(f"使用 Paddle 专用高亮器 | 页面: {page_idx}")
                image_url = PaddlePDFHighlighter.highlight_single_region(
                    pdf_path=pdf_input,
                    page_num=page_idx,
                    bbox=bbox
                )
            else:
                # 使用通用高亮器（MinerU 等，200 DPI，点坐标）
                logger.info(f"使用通用高亮器 | 页面: {page_idx}")
                image_url = PDFHighlighter.highlight_single_region(
                    pdf_path=pdf_input,
                    page_num=page_idx,
                    bbox=bbox
                )
            
            return image_url
            
        except Exception as e:
            logger.error(f"生成PDF高亮失败: {e}", exc_info=True)
            return None
    
    async def _generate_merged_highlight(
        self,
        file_path: Union[str, bytes],
        page_bboxes: List[Tuple[int, Optional[List[float]]]],
        backend: str = "mineru"  # 新增参数：解析后端
    ) -> Optional[str]:
        """
        生成合并的高亮图（V2 - 支持同页多框 + 跨页）
        
        将多个页面（或同一页面的多个区域）的高亮图合并成一张图片
        
        处理逻辑：
        1. 按页码分组 bbox
        2. 对每个页面，在同一张底图上绘制该页所有的 bbox
        3. 如果涉及多页，垂直拼接所有页面
        
        Args:
            file_path: PDF文件路径、URL 或 bytes 内容
            page_bboxes: 页码和bbox的列表 [(page_idx, bbox), ...]
            backend: 解析后端（'paddle' 或 'mineru'），默认 'mineru'
            
        Returns:
            Optional[str]: 合并后的MinIO URL 或 None（失败时）
        """
        try:
            from PIL import Image
            from io import BytesIO
            import tempfile
            from collections import defaultdict
            from src.utils.minio.client import get_minio_client
            
            # 准备 PDF 输入
            pdf_input = file_path
            if isinstance(file_path, str) and file_path.startswith(('http://', 'https://')):
                pdf_content, _ = await FileHandler._download_from_url(file_path)
                pdf_input = pdf_content
            elif isinstance(file_path, bytes):
                pdf_input = file_path
            
            # 1. 按页码分组 bbox
            page_groups = defaultdict(list)  # {page_idx: [bbox1, bbox2, ...]}
            for page_idx, bbox in page_bboxes:
                if bbox is None:
                    continue
                page_groups[page_idx].append(bbox)
            
            if not page_groups:
                logger.error("没有有效的 bbox 数据")
                return None
            
            # 2. 为每个页面生成高亮图（同页多框会在同一张图上显示）
            page_images = []
            for page_idx in sorted(page_groups.keys()):
                bboxes = page_groups[page_idx]
                logger.info(f"页面 {page_idx} 有 {len(bboxes)} 个高亮框")
                
                # 调用支持多框高亮的方法
                image_url = await self._generate_multi_bbox_highlight(
                    pdf_input=pdf_input,
                    page_idx=page_idx,
                    bboxes=bboxes,
                    backend=backend
                )
                
                if image_url:
                    # 从 MinIO 下载图片
                    image_content, _ = await FileHandler._download_from_url(image_url)
                    img = Image.open(BytesIO(image_content))
                    page_images.append((page_idx, img))
                    logger.debug(f"生成页面 {page_idx} 高亮图，尺寸: {img.size}")
            
            if not page_images:
                logger.error("没有成功生成任何页面的高亮图")
                return None
            
            # 3. 如果只有一页，直接返回已上传的 URL（避免重复上传）
            if len(page_images) == 1:
                logger.info(f"只有一页，直接使用已生成的图片")
                return image_url  # 返回最后一次生成的 URL
            
            # 4. 多页：垂直拼接
            images_only = [img for _, img in page_images]
            max_width = max(img.width for img in images_only)
            total_height = sum(img.height for img in images_only)
            
            # 添加页面之间的间隔
            page_gap = 20
            total_height += page_gap * (len(images_only) - 1)
            
            logger.info(f"合并 {len(images_only)} 张图片，总尺寸: {max_width}x{total_height}")
            
            # 创建合并后的图片
            merged_image = Image.new('RGB', (max_width, total_height), 'white')
            
            # 垂直拼接
            current_y = 0
            for i, img in enumerate(images_only):
                x_offset = (max_width - img.width) // 2
                merged_image.paste(img, (x_offset, current_y))
                current_y += img.height
                
                # 添加分隔线
                if i < len(images_only) - 1:
                    from PIL import ImageDraw
                    draw = ImageDraw.Draw(merged_image)
                    line_y = current_y + page_gap // 2
                    draw.line([(10, line_y), (max_width - 10, line_y)], fill='gray', width=2)
                    current_y += page_gap
            
            # 保存并上传
            with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file:
                merged_image.save(tmp_file, format='PNG', quality=95)
                tmp_path = tmp_file.name
            
            try:
                minio_client = get_minio_client()
                with open(tmp_path, 'rb') as f:
                    image_bytes = f.read()
                
                import hashlib
                file_hash = hashlib.md5(image_bytes).hexdigest()[:8]
                timestamp = int(time.time() * 1000)
                object_name = f"highlights/merged_{timestamp}_{file_hash}.png"
                
                url = minio_client.upload_bytes(
                    data=image_bytes,
                    object_name=object_name,
                    content_type='image/png'
                )
                
                logger.info(f"多页高亮图合并成功，已上传到: {url}")
                return url
                
            finally:
                try:
                    Path(tmp_path).unlink()
                except:
                    pass
            
        except Exception as e:
            logger.error(f"生成合并高亮图失败: {e}", exc_info=True)
            return None
    
    async def _generate_multi_bbox_highlight(
        self,
        pdf_input: Union[str, bytes],
        page_idx: int,
        bboxes: List[List[float]],
        backend: str = "mineru"
    ) -> Optional[str]:
        """
        在同一页面上绘制多个 bbox 高亮框
        
        Args:
            pdf_input: PDF 文件路径、URL 或 bytes
            page_idx: 页码索引
            bboxes: bbox 列表 [[x0,y0,x1,y1], ...]
            backend: 解析后端
            
        Returns:
            Optional[str]: MinIO URL
        """
        try:
            # 选择高亮器
            if backend == "paddle":
                highlighter = PaddlePDFHighlighter
            else:
                highlighter = PDFHighlighter
            
            # 检查高亮器是否支持多 bbox
            if hasattr(highlighter, 'highlight_multiple_regions_single_page'):
                # 如果有专用方法，使用它
                return highlighter.highlight_multiple_regions_single_page(
                    pdf_path=pdf_input,
                    page_num=page_idx,
                    bboxes=bboxes
                )
            else:
                # 降级：逐个绘制并合并（或者只画第一个框）
                # 这里简化处理：合并所有 bbox 为一个大框
                if len(bboxes) == 1:
                    return highlighter.highlight_single_region(
                        pdf_path=pdf_input,
                        page_num=page_idx,
                        bbox=bboxes[0]
                    )
                else:
                    # 计算包围所有 bbox 的最小矩形
                    x0 = min(b[0] for b in bboxes)
                    y0 = min(b[1] for b in bboxes)
                    x1 = max(b[2] for b in bboxes)
                    y1 = max(b[3] for b in bboxes)
                    merged_bbox = [x0, y0, x1, y1]
                    
                    logger.info(f"合并 {len(bboxes)} 个框为一个大框: {merged_bbox}")
                    return highlighter.highlight_single_region(
                        pdf_path=pdf_input,
                        page_num=page_idx,
                        bbox=merged_bbox
                    )
        except Exception as e:
            logger.error(f"生成多框高亮失败: {e}", exc_info=True)
            return None
    
    async def extract_fields_stream(
        self,
        request: ExtractionRequest
    ):
        """
        流式字段提取
        
        使用生成器逐步返回提取进度和结果
        同时更新任务状态（如果提供task_id）
        
        Args:
            request: 提取请求
            
        Yields:
            StreamEvent: 流式事件（任务开始、字段进度、字段完成、任务完成、错误）
        """
        start_time = time.time()
        
        # 生成任务ID
        task_id = request.task_id or request.request_id
        task_created = False
        
        try:
            # 创建任务（如果有task_id或request_id）
            if task_id:
                await self.task_store.create_task(
                    task_id=task_id,
                    task_type=TaskType.EXTRACTION,
                    context={
                        "extraction_type": request.extraction_type,
                        "rule_config_id": request.rule_config_id
                    }
                )
                task_created = True
                await self.task_store.update_task(task_id, status=TaskStatus.PROCESSING, progress=0.0)
            # 1. 加载规则配置
            if request.rule_config_id:
                logger.info(f"加载规则配置: {request.rule_config_id}")
                config = self.rule_service.get_config(request.rule_config_id)
                if not config:
                    yield ErrorEvent(
                        event_type="error",
                        data={
                            "error_message": f"规则配置不存在: {request.rule_config_id}",
                            "request_id": request.request_id
                        }
                    )
                    return
            else:
                # 根据extraction_type查找默认规则配置
                logger.info(f"未提供rule_config_id，尝试根据extraction_type查找默认规则: {request.extraction_type}")
                business_type = map_extraction_type_to_business_type(request.extraction_type)
                
                if business_type is None:
                    yield ErrorEvent(
                        event_type="error",
                        data={
                            "error_message": (
                                f"无法识别的extraction_type: {request.extraction_type}。"
                                f"支持的类型: 有纸大宗实物, 无纸大宗实物, 有纸小宗实物, 无纸小宗实物"
                            ),
                            "request_id": request.request_id
                        }
                    )
                    return
                
                config = self.rule_service.get_default_config_by_business_type(business_type)
                
                if config is None:
                    yield ErrorEvent(
                        event_type="error",
                        data={
                            "error_message": (
                                f"未找到业务类型 '{business_type.value}' 的启用规则配置。"
                                f"请先在规则配置管理中创建并启用对应的规则配置，或者显式提供rule_config_id参数。"
                            ),
                            "request_id": request.request_id
                        }
                    )
                    return
                
                logger.info(f"使用默认规则配置: {config.id} ({config.name})")
            
            # 统计总字段数（只统计启用的）
            enabled_rules = [r for r in config.extraction_rules if r.enabled]
            total_fields = len(enabled_rules)
            
            # 发送任务开始事件
            yield TaskStartEvent(
                event_type="task_start",
                data={
                    "task_id": request.request_id,
                    "total_fields": total_fields,
                    "extraction_type": request.extraction_type
                }
            )
            
            # 2. 解析所有文件
            logger.info(f"开始解析 {len(request.files)} 个文件")
            parsed_files = await self._parse_files(request.files)
            
            # 3. 流式提取字段
            field_results = []
            success_count = 0
            failed_count = 0
            
            for idx, rule in enumerate(enabled_rules):
                field_idx = idx + 1
                
                # 计算进度
                progress = field_idx / total_fields
                
                # 更新任务进度
                if task_created and task_id:
                    await self.task_store.update_task(task_id, progress=progress)
                
                # 发送字段进度事件
                yield FieldProgressEvent(
                    event_type="field_progress",
                    data={
                        "field_id": rule.id,
                        "field_name": rule.field_name,
                        "progress": progress,
                        "current": field_idx,
                        "total": total_fields
                    }
                )
                
                # 提取单个字段
                result = await self._extract_single_field(
                    rule=rule,
                    parsed_files=parsed_files,
                    request=request
                )
                
                field_results.append(result)
                
                # 成功：至少有一个来源提取成功
                if len(result.values) > 0:
                    success_count += 1
                else:
                    failed_count += 1
                
                # 发送字段完成事件
                yield FieldCompleteEvent(
                    event_type="field_complete",
                    data={
                        "field_id": result.field_id,
                        "field_name": result.field_name,
                        "field_type": result.field_type,
                        "values": [v.model_dump() for v in result.values],
                        "error_message": result.error_message
                    }
                )
            
            # 4. 确定整体状态
            if success_count == total_fields:
                overall_status = ExtractionStatus.SUCCESS
            elif success_count == 0:
                overall_status = ExtractionStatus.FAILED
            else:
                overall_status = ExtractionStatus.PARTIAL
            
            processing_time = int((time.time() - start_time) * 1000)
            
            # 更新任务状态为完成
            if task_created and task_id:
                await self.task_store.update_task(
                    task_id,
                    status=TaskStatus.COMPLETED,
                    progress=1.0,
                    result={
                        "status": overall_status.value,
                        "extraction_type": request.extraction_type,
                        "rule_config_id": config.id,
                        "total_fields": total_fields,
                        "success_count": success_count,
                        "failed_count": failed_count,
                        "processing_time_ms": processing_time
                    }
                )
            
            # 发送任务完成事件
            yield TaskCompleteEvent(
                event_type="task_complete",
                data={
                    "task_id": task_id or request.request_id,
                    "status": overall_status.value,
                    "extraction_type": request.extraction_type,
                    "rule_config_id": config.id,
                    "total_fields": total_fields,
                    "success_count": success_count,
                    "failed_count": failed_count,
                    "processing_time_ms": processing_time
                }
            )
            
        except Exception as e:
            logger.error(f"流式字段提取失败: {e}", exc_info=True)
            
            # 更新任务状态为失败
            if task_created and task_id:
                await self.task_store.update_task(
                    task_id,
                    status=TaskStatus.FAILED,
                    error_message=str(e)
                )
            
            yield ErrorEvent(
                event_type="error",
                data={
                    "error_message": str(e),
                    "request_id": request.request_id
                }
            )
    
    def _locate_file_by_page(
        self,
        file_data: Dict[str, Any],
        global_page_idx: int
    ) -> tuple[Optional[str], int]:
        """
        根据全局页码找到对应的文件路径
        
        当一个 file_type 包含多个文件时，页码是连续累加的。
        这个方法根据全局页码，找到是哪个文件。
        
        Args:
            file_data: 文件数据字典
            global_page_idx: 全局页码（合并后的页码）
            
        Returns:
            tuple: (文件路径, 文件索引)
        """
        parse_results = file_data.get("parse_results", [])
        file_paths = file_data.get("file_paths", [])
        
        if not parse_results or not file_paths:
            return None, -1
        
        # 累加页数，找到对应的文件
        page_offset = 0
        for i, parse_result in enumerate(parse_results):
            page_count = len(parse_result.pages)
            if global_page_idx < page_offset + page_count:
                return file_paths[i], i
            page_offset += page_count
        
        # 如果没找到，返回第一个文件（兜底）
        logger.warning(f"页码 {global_page_idx} 超出范围，使用第一个文件")
        return file_paths[0], 0
    
    def _calculate_actual_page_idx(
        self,
        file_data: Dict[str, Any],
        file_index: int,
        global_page_idx: int
    ) -> int:
        """
        计算在单个文件中的实际页码
        
        Args:
            file_data: 文件数据字典
            file_index: 文件索引
            global_page_idx: 全局页码
            
        Returns:
            int: 在单个文件中的实际页码
        """
        parse_results = file_data.get("parse_results", [])
        
        # 计算该文件之前所有文件的总页数
        page_offset = 0
        for i in range(file_index):
            if i < len(parse_results):
                page_offset += len(parse_results[i].pages)
        
        # 全局页码减去偏移量
        return global_page_idx - page_offset
    
    def _detect_cross_page_table(
        self,
        parse_results: List[Any],
        file_index: int,
        page_idx: int
    ) -> Optional[List[int]]:
        """
        检测跨页表格并返回所有相关页面
        
        当表格跨页时，MinerU 会将内容合并到第一页，后续页面标记为 lines_deleted。
        此方法检测这种情况并返回所有相关页码。
        
        Args:
            parse_results: 解析结果列表
            file_index: 文件索引
            page_idx: 当前页码
            
        Returns:
            Optional[List[int]]: 相关页码列表；如果不是跨页表格返回 None
        """
        try:
            if file_index < 0 or file_index >= len(parse_results):
                return None
            
            parse_result = parse_results[file_index]
            if page_idx < 0 or page_idx >= len(parse_result.pages):
                return None
            
            current_page = parse_result.pages[page_idx]
            
            # 检查当前页是否有跨页表格标记
            has_continuation = False
            for block in current_page.layout_blocks:
                if getattr(block, 'is_continuation', False):
                    has_continuation = True
                    break
            
            if not has_continuation:
                # 不是跨页表格的延续页
                # 但可能是跨页表格的起始页，需要向后查找
                related_pages = [page_idx]
                
                # 向后查找连续的延续页
                for next_idx in range(page_idx + 1, len(parse_result.pages)):
                    next_page = parse_result.pages[next_idx]
                    has_next_continuation = False
                    
                    for block in next_page.layout_blocks:
                        if getattr(block, 'is_continuation', False):
                            has_next_continuation = True
                            break
                    
                    if has_next_continuation:
                        related_pages.append(next_idx)
                    else:
                        break  # 不再是连续的延续页
                
                # 如果找到了延续页，返回所有相关页
                if len(related_pages) > 1:
                    logger.info(f"检测到跨页表格起始页 {page_idx}，延续到页面 {related_pages[-1]}")
                    return related_pages
                
                return None
            else:
                # 当前页是延续页，需要向前找到起始页
                logger.info(f"页面 {page_idx} 是跨页表格的延续页，向前查找起始页")
                
                # 向前查找起始页（没有 is_continuation 标记的表格页）
                start_page_idx = page_idx
                for prev_idx in range(page_idx - 1, -1, -1):
                    prev_page = parse_result.pages[prev_idx]
                    
                    # 检查前一页是否有表格
                    has_table = False
                    has_prev_continuation = False
                    
                    for block in prev_page.layout_blocks:
                        if block.category_type == 'table':
                            has_table = True
                            if getattr(block, 'is_continuation', False):
                                has_prev_continuation = True
                    
                    if has_table and not has_prev_continuation:
                        # 找到起始页
                        start_page_idx = prev_idx
                        break
                    elif has_table and has_prev_continuation:
                        # 还是延续页，继续向前找
                        start_page_idx = prev_idx
                    else:
                        # 不是表格页，停止查找
                        break
                
                # 从起始页向后收集所有相关页
                related_pages = [start_page_idx]
                for next_idx in range(start_page_idx + 1, len(parse_result.pages)):
                    next_page = parse_result.pages[next_idx]
                    has_next_continuation = False
                    
                    for block in next_page.layout_blocks:
                        if getattr(block, 'is_continuation', False):
                            has_next_continuation = True
                            break
                    
                    if has_next_continuation or next_idx == page_idx:
                        related_pages.append(next_idx)
                    else:
                        break
                
                logger.info(f"跨页表格完整页码范围: {related_pages}")
                return related_pages if len(related_pages) > 1 else None
                
        except Exception as e:
            logger.error(f"检测跨页表格失败: {e}", exc_info=True)
            return None
    
    def _get_page_bbox(
        self,
        parse_results: List[Any],
        file_index: int,
        page_idx: int
    ) -> Optional[List[float]]:
        """
        获取指定页面的表格 bbox
        
        Args:
            parse_results: 解析结果列表
            file_index: 文件索引
            page_idx: 页码
            
        Returns:
            Optional[List[float]]: bbox 坐标 [x0, y0, x1, y1]；未找到返回 None
        """
        try:
            if file_index < 0 or file_index >= len(parse_results):
                return None
            
            parse_result = parse_results[file_index]
            if page_idx < 0 or page_idx >= len(parse_result.pages):
                return None
            
            page = parse_result.pages[page_idx]
            
            # 查找表格块的 bbox
            for block in page.layout_blocks:
                if block.category_type == 'table':
                    # 兼容处理：支持 bbox 为对象或列表
                    if hasattr(block.bbox, 'x0'):
                        return [block.bbox.x0, block.bbox.y0, block.bbox.x1, block.bbox.y1]
                    elif isinstance(block.bbox, (list, tuple)) and len(block.bbox) == 4:
                        return list(block.bbox)
            
            # 如果没找到表格块，尝试从 contents 中找
            for content in page.contents:
                if content.type == 'table' and hasattr(content, 'bbox') and content.bbox:
                    if hasattr(content.bbox, 'x0'):
                        return [content.bbox.x0, content.bbox.y0, content.bbox.x1, content.bbox.y1]
                    elif isinstance(content.bbox, (list, tuple)) and len(content.bbox) == 4:
                        return list(content.bbox)
            
            logger.warning(f"页面 {page_idx} 未找到表格 bbox")
            return None
            
        except Exception as e:
            logger.error(f"获取页面 bbox 失败: {e}", exc_info=True)
            return None

